<?php

abstract class Mongo_Document
{

    /**
     * collection class name
     * @var string
     */
    protected $_collection = null;

    /**
     * Whether or not this collection is a gridFS collection If using a
     * corresponding Mongo_Collection subclass, set this only in the
     * Mongo_Collection subclass.
     *
     *  @var  boolean
     */
    protected $_gridFS = FALSE;

    /**
     * Designated place for non-persistent data storage (will not be saved to
     * the database or after sleep)
     *
     *  @var  array
     */
    public $__data = array();

    /**
     * Internal storage of object data
     *
     *  @var  array
     */
    protected $_data = array();

    /**
     *  Keep track of fields changed using __set or load_values
     *
     *  @var  array
     */
    protected $_changed = array();

    /**
     *  Set of operations to perform on update/insert
     *
     *  @var  array
     */
    protected $_operation = array();

    /**
     *  @var  boolean
     */
    protected $_loaded = NULL;

    /**
     *
     * @param   string  model name
     * @param   mixed   optional _id of document to operate on or criteria for
     * loading (if you expect it exists)
     * @return  Mongo_Document
     */
    public static function factory($name, $load = NULL)
    {
        $class = 'Model_' . implode('_', array_map('ucfirst', explode('_', $name)));
        return new $class($load);
    }

    /**
     * Instantiate a new Document object. If an id or other data is passed then
     * it will be assumed that the
     * document exists in the database and updates will be performaed without
     * loading the document first.
     *
     * @param   string  The _id of the document to operate on or criteria used to
     * load
     * @return  void
     */
    public function __construct($id = NULL)
    {
        if ($id !== NULL)
        {
            if (is_array($id))
            {
                $this -> _data = $id;
            }
            else
            {
                $this -> _data['_id'] = $id;
            }
        }
    }

    /**
     * Returns the attributes that should be serialized.
     *
     * @return  void
     */
    public function __sleep()
    {
        return array(
            '_data',
            '_changed',
            '_operation',
            '_loaded'
        );
    }

    /**
     * Checks if a field is set
     *
     * @return  boolean  field is set
     */
    public function __isset($key)
    {
        return isset($this -> _data[$key]);
    }

    /**
     * Unset a field
     *
     * @return  void
     */
    public function __unset($key)
    {
        if (isset($this -> _data[$key]))
        {
            unset($this -> _data[$key]);
            $this -> _dirty[$key] = TRUE;
        }
    }

    /**
     * Clear the document data
     *
     * @return  Mongo_Document
     */
    public function clear()
    {
        $this -> _data = $this -> _changed = $this -> _operation = array();
        $this -> _loaded = NULL;
        return $this;
    }

    /**
     * Return TRUE if field has been changed
     *
     * @param   string   $name  field name (no parameter returns TRUE if there
     * are *any* changes)
     * @return  boolean  field has been changed
     */
    public function is_changed($key = NULL)
    {
        if ($key === NULL)
        {
            return ($this -> _changed || $this -> _operation);
        }
        else
        {
            return isset($this -> _changed[$key]);
        }
    }

    /**
     * Gets one of the following:
     *
     *  - A referenced object
     *  - A search() result
     *  - A field's value
     *
     * @param   string  field name
     * @return  mixed
     */
    public function __get($key)
    {
        if ($this -> _loaded === NULL && $key != '_id')
        {
            $this -> load();
        }

        return isset($this -> _data[$key]) ? $this -> _data[$key] : NULL;
    }

    /**
     * Magic method for setting the value of a field. In order to set the value
     * of a nested field,
     * you must use the "set" method, not the magic method. Examples:
     *
     * <code>
     * // Works
     * $doc->set('address.city', 'Knoxville');
     *
     * // Does not work
     * $doc->address['city'] = 'Knoxville';
     * </code>
     *
     * @param   string  field name
     * @param   mixed   new field value
     * @return  mixed
     */
    public function __set($key, $value)
    {
        $this -> _data[$key] = $value;
        $this -> _changed[$key] = TRUE;
    }

    /**
     * Set the value for a key. This function must be used when updating nested
     * documents.
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @param   mixed   $value The data to be saved
     * @return  Mongo_Document
     */
    public function _set($key, $value)
    {
        $this -> _operation['$set'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @return Mongo_Document
     */
    public function _unset($key)
    {
        $this -> _operation['$unset'][$key] = 1;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Increment a value atomically
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @param   mixed   $value The amount to increment by (default is 1)
     * @return  Mongo_Document
     */
    public function _inc($key, $value = 1)
    {
        $this -> _operation['$inc'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Push a vlaue to an array atomically. Can be called multiple times.
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @param   mixed   $value The value to push
     * @return  Mongo_Document
     */
    public function _push($key, $value)
    {
        $this -> _operation['$push'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Push an array of values to an array in the document
     * @return  Mongo_Document
     */
    public function _pushAll($key, $value)
    {
        $this -> _operation['$pushAll'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Pop a value from the end of an array
     *
     * @param   string  $name The key of the data to update
     * @return  Mongo_Document
     */
    public function _pop($key)
    {
        $this -> _operation['$pop'][$key] = 1;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Pop a value from the beginning of an array
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @return  Mongo_Document
     */
    public function _shift($key)
    {
        $this -> _operation['$pop'][$key] = -1;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Pull (delete) a value from an array
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @param   mixed   $value
     * @return  Mongo_Document
     */
    public function _pull($key, $value)
    {
        $this -> _operation['$pull'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Pull (delete) all of the given values from an array
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @param   array   $value An array of value to pull from the array
     * @return  Mongo_Document
     */
    public function _pullAll($key, $value)
    {
        $this -> _operation['$pullAll'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Bit operators
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @return  Mongo_Document
     */
    public function _bit($key, $value)
    {
        $this -> _operation['$bit'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Adds value to the array only if its not in the array already.
     *
     * @param   string  $name The key of the data to update (use dot notation for
     * embedded objects)
     * @param   mixed   $value  The value to add to the set
     * @return  Mongo_Document
     */
    public function _addToSet($key, $value)
    {
        $this -> _operation['$addToSet'][$key] = $value;
        $this -> _dirty[$key] = TRUE;
        return $this;
    }

    /**
     * Load all of the values in an associative array. Ignores all fields
     * not in the model.
     *
     * @param   array    field => value pairs
     * @param   boolean  values are clean (from database)?
     * @return  Mongo_Document
     */
    public function load_values($values, $clean = FALSE)
    {
        $this -> _data = $values;
        $this -> _loaded = !empty($values);
        return $this;
    }

    /**
     * Get the model data as an associative array.
     * @return  array  field => value
     */
    public function as_array()
    {
        return $this -> _data;
    }

    /**
     * Return true if the document is loaded.
     *
     * @return  boolean
     */
    public function is_loaded()
    {
        return $this -> _loaded;
    }

    /**
     * Load the document from the database. The first parameter may be one of:
     *
     *  a falsey value - the object data will be used to construct the query
     *  a JSON string - will be parsed and used for the query
     *  an non-array value - the query will be assumed to be for an _id of this
     * value
     *  an array - the array will be used for the query
     *
     * @param   array  specify additional criteria
     * @param   array  specify the fields to return
     * @return  boolean  TRUE if the load succeeded
     */
    public function load($criteria = null, array $fields = array())
    {
        // Use of json for querying is allowed
        if (is_string($criteria))
        {
            $criteria = array('_id' => $criteria);
        }
        else
        if (isset($this -> _data['_id']))
        {
            $criteria = array('_id' => $this -> _data['_id']);
        }

        if (!$criteria)
        {
            throw new MongoException('Cannot find ' . get_class($this) . ' without search criteria.');
        }

        $values = Mongo_Db::inst($this -> _db) -> find_one($criteria);

        // Only clear the object if necessary
        if ($this -> _loaded !== NULL || $this -> _changed || $this -> _operation)
        {
            $this -> clear();
        }

        $this -> load_values($values, TRUE);
        return $this -> _loaded;
    }

    /**
     * Save the document to the database. For newly created documents the _id
     * will be retrieved.
     *
     * @param   boolean  $safe  If FALSE the insert status will not be checked
     * @return  Mongo_Document
     */
    public function save($safe = TRUE)
    {
        $db = Mongo_Db::inst($this -> _db);

        // Insert new record if no _id or _id was set by user
        if (!isset($this -> _data['_id']) || isset($this -> _changed['_id']))
        {

            $this -> before_create();

            $values = array();

            foreach ($this->_changed as $key => $_true)
            {
                $values[$key] = $this -> _data[$key];
            }

            if (empty($values))
            {
                throw new MongoException('Cannot insert empty array.');
            }

            $err = $db -> insert($this -> _collection, $values, array('safe' => $safe));

            if ($safe && $err['err'])
            {
                throw new MongoException('Unable to insert ' . get_class($this) . ': ' . $err['err']);
            }

            $this -> after_create();

            if (!isset($this -> _data['_id']))
            {
                // Store (assigned) MongoID in object
                $this -> _data['_id'] = $values['_id'];
                $this -> _loaded = TRUE;
            }

            // Save any additional operations
            /** @todo  Combine operations into the insert when possible to avoid
             * this update */
            if ($this -> _operation)
            {
                if (!$db -> update($this -> _collection, array('_id' => $this -> _data['_id']), $this -> _operation))
                {
                    $err = $db -> last_error();
                    throw new MongoException('Update of ' . get_class($this) . ' failed: ' . $err['err']);
                }
            }
        }

        // Update assumed existing document
        else
        {
            if ($this -> _changed)
            {
                foreach ($this->_changed as $key => $_true)
                {
                    $this -> _operations['$set'][$key] = $this -> _object[$key];
                }
            }

            if ($this -> _operation)
            {

                if (!$db -> update($this -> _collection, array('_id' => $this -> _data['_id']), $this -> _operation))
                {
                    $err = $this -> db() -> last_error();
                    throw new MongoException('Update of ' . get_class($this) . ' failed: ' . $err['err']);
                }
                $this -> _changed = $this -> _operation = array();
            }
        }

        return $this;
    }

    public function collection()
    {
        return Mongo_Db::inst($this -> _db);
    }

    /**
     * Delete the current document using the current data. The document does not
     * have to be loaded.
     * Use $doc->collection()->remove($criteria) to delete multiple documents.
     *
     * @return  Mongo_Document
     */
    public function delete($safe = TRUE)
    {

        if (!isset($this -> _data['_id']))
        {
            throw new MongoException('Cannot delete ' . get_class($this) . ' without the _id.');
        }

        $criteria = array('_id' => $this -> _data['_id']);

        $this -> before_delete();

        if (Mongo_Db::inst($this -> _db) -> remove($this -> _collection, $criteria, array('safe' => $safe)))
        {
            throw new MongoException('Failed to delete ' . get_class($this));
        }

        $this -> clear();
        $this -> after_delete();
        return $this;
    }

    /**
     * array_merge_recursive_distinct does not change the datatypes of the values
     * in the arrays.
     * @param array $array1
     * @param array $array2
     * @return array
     */
    protected static function array_merge_recursive_distinct(array &$array1, array &$array2)
    {
        $merged = $array1;

        foreach ($array2 as $key => &$value)
        {
            if (is_array($value) && isset($merged[$key]) && is_array($merged[$key]))
            {
                $merged[$key] = self::array_merge_recursive_distinct($merged[$key], $value);
            }
            else
            {
                $merged[$key] = $value;
            }
        }

        return $merged;
    }

    protected function before_create()
    {
    }

    protected function after_create()
    {
    }

    protected function before_update()
    {
    }

    protected function after_update()
    {
    }

    protected function before_delete()
    {
    }

    protected function after_delete()
    {
    }

}
